-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix(react-utilities): drag scrollbar should invoke callback in useOnScrollOutside with extending event type
#28964
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(react-utilities): drag scrollbar should invoke callback in useOnScrollOutside with extending event type
#28964
Conversation
useOnClickOutsideuseOnScrollOutside
|
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit 3320a87:
|
Perf Analysis (
|
| Scenario | Render type | Master Ticks | PR Ticks | Iterations | Status |
|---|---|---|---|---|---|
| FluentProviderWithTheme | virtual-rerender-with-unmount | 79 | 76 | 10 | Possible regression |
All results
| Scenario | Render type | Master Ticks | PR Ticks | Iterations | Status |
|---|---|---|---|---|---|
| Avatar | mount | 609 | 617 | 5000 | |
| Button | mount | 317 | 317 | 5000 | |
| Field | mount | 1105 | 1128 | 5000 | |
| FluentProvider | mount | 690 | 693 | 5000 | |
| FluentProviderWithTheme | mount | 84 | 82 | 10 | |
| FluentProviderWithTheme | virtual-rerender | 65 | 64 | 10 | |
| FluentProviderWithTheme | virtual-rerender-with-unmount | 79 | 76 | 10 | Possible regression |
| InfoButton | mount | 16 | 14 | 5000 | |
| MakeStyles | mount | 859 | 852 | 50000 | |
| Persona | mount | 1727 | 1641 | 5000 | |
| SpinButton | mount | 1379 | 1333 | 5000 |
📊 Bundle size reportUnchanged fixtures
|
🕵 fluentuiv9 No visual regressions between this PR and main |
Asset size changesSize Auditor did not detect a change in bundle size for any component! Baseline commit: 5d2f8b98de08a514bd5b8fa0f3d69adf4e6f426d (build) |
packages/react-components/react-utilities/src/hooks/useOnScrollOutside.ts
Outdated
Show resolved
Hide resolved
d58d71b to
fad2512
Compare
|
/azp run |
|
Azure Pipelines successfully started running 4 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 4 pipeline(s). |
packages/react-components/react-popover/src/components/Popover/Popover.test.tsx
Show resolved
Hide resolved
|
/azp run |
|
Azure Pipelines successfully started running 4 pipeline(s). |
| export type OpenPopoverEvents = | ||
| | Event | ||
| | MouseEvent | ||
| | TouchEvent | ||
| | React.FocusEvent<HTMLElement> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately I think this is a breaking change. Existing onOpenChange handlers aren't prepared to handle events other than MouseEvent, TouchEvent, or FocusEvent. The only way I can think of to make this not breaking is to create an onOpenChange2 event with the new type for the event arg.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah good catch 👍, I tried playing with this a bit more in TS playground and it can indeed break typescript build depending on how users define their onOpenChange handlers https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBDAnmYcDyKB2AFCkBuwUAooZjAM5wC8AUHHAD5ynDn1NwCyEArhcFbsGzACp8AxgAshMWvKQo42KHirU4Abw4MImDGwDCUgIaYA5sABccABTX0WXASKyKAShoA+OPggBLABMAbloAX3kJPQp4PQNMYzNLGjsHHn5BMngxSRksz2ofbXDI6PgwVTAKGxU1FM04OKxEi1Qw0NogA
This is actually quite worrying because we will never be able to add new events to our callback types without breaking users 😱 - this looks like a good candidate to discuss in tech sync with the wider team
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case we might want to consider casting internally in the Popover and Menu components to one of the existing event types, to unblock the fixing of the bugs in the description. It will change the runtime behaviour, but I think that is acceptable compared to the risk of build breaks for users.
However actually adding new events to types in useOnScrollOutside is also technically a breaking change in that case, even if the utility is not exported by our suite package 😱😱 - if we want full non-break coverage we should probably revert to @YuanboXue-Amber's original implementation and even cast the new event type within the utility.
All options are pretty bad for me, WDYT @behowell?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original implementation is at this commit: b73b2bd
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Casting to avoid a breaking change in types is still a breaking change; and possibly even worse: it results in extra-confusing runtime bugs when the object isn't the type that was promised. In this case, the event object would be missing any of the properties that are in MouseEvents.
I think in practice, it's pretty unlikely that it will cause an actual problem in this case, especially since the existing type includes React.FocusEvent<HTMLElement> and already requires you to do some runtime inspection of the object to see what type it is.
It might be worth discussing this with the rest of the team to come up with the right fix here. This isn't the first time this has caused a problem. In my recent PR #28951, I had to come up with a "hacky" workaround to this problem, by including the new event on the data argument instead. Unfortunately, that won't work here because it requires passing undefined for the event argument, which would be a breaking change here (the OpenPopoverEvents type doesn't include undefined as a possibility).
All of the possible options I can think of are broken in some way, so we'd have to decide what the least-bad option is:
- Add
| Eventto theOpenPopoverEventstype (this PR).- Pros: Gets the type correct. Any potential breaks are caught at build time.
- Cons: It's a breaking change for any code that explicitly types its event argument (instead of using our exported
OpenPopoverEventstype).
- Cast the event type to
MouseEvent.- Pros: No type changes, and no changes to existing code to listen to the new case where
onOpenChangecan be called. - Cons: Still a breaking change, but the break happens at runtime instead of build time. The type is a lie.
- Pros: No type changes, and no changes to existing code to listen to the new case where
- Create
onOpenChange2with the new types, and deprecateonOpenChange.- Pros: No type breaks to existing code.
- Cons: Breaks the contract of the old
onOpenChange: it won't be called when the popover hides in response to a scrollbar. Code will need to be updated toonOpenChange2. Also, this adds a bunch of code to keep both the old event and new event working.
Given all of that, I think option 1 might actually be the least-bad option.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think that 1. might be the least bad option, but it would require discussion with the wider team to make sure we know about it and the consequences. I would propose that we:
- Implement option 2 (casting the event) to fix the standing bug
- create an issue to track whether we stick to option 2 or switch to option 1
- discuss the new issue in tech sync
In the interim option 2 is the best case IMO, since build breaks generally block our users from upgrading to fix other issues. I can't as easily think of cases where users rely on knowing exactly what the event type is. However for option 1 it's quite common from what I see in codebases to type the event
// 👍 no need to do anything
const onOpenChange: PopoverProps['onOpenChange'] = (e, data) => {/**/}
// 👎 breaking change - I've generally seen this more often since developers generally don't pick from PopoverProps as much
const onOpenChange = (e: React.MouseEvent | React.FocusEvent, data: PopoverOpenData) => {/**/}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I created a new PR #29062 using the cast option (option 2) to fix the original issue. Let's discuss how to extend event type this wednesday
| export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & { | ||
| /** | ||
| * Called if the scroll is outside the element refs | ||
| */ | ||
| callback: (ev: Event | MouseEvent | TouchEvent) => void; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this have /** @internal */ since it appears to only be used by an internal utility?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could add it, but I'm not sure what the use of @internal is anymore, even if these utilties are internal we are still bound by the requirement to never break them until the next major, now that our API surface includes our entire set of packages 🥲
| import type { UseOnClickOrScrollOutsideOptions } from './useOnClickOutside'; | ||
| import { UseOnClickOutsideOptions } from './useOnClickOutside'; | ||
|
|
||
| export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I can tell, the goal here is to use UseOnClickOutsideOptions, except replace the callback field? If so, it'd be better to use Omit instead of Pick:
| export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & { | |
| export type UseOnScrollOutsideOptions = Omit<UseOnClickOutsideOptions, 'callback'> & { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd personally prefer Pick since there's less risk of picking up any new properties unintentionally in the future. Since it's only types, there should be no consequences the extra code to pick explicit properties.
Pick will also break if any properties are removed - which Omit will not do https://www.typescriptlang.org/play?#code/C4TwDgpgBAYg9nAvFA3gKCpqAjAhgJwC4oA7AVwFtsJ8BuDLPAL2OwQBsJcT6BfNNKEhQAQgSjIA8hQCWwADzw4AGigByZmoB89QeGhimEqAAUZAYwDWihKo0E1UAD7qAZgm31MQA
useOnScrollOutsideuseOnScrollOutside with extending event type

Fix issue 1+2 in #28938
Previous Behavior
In the codesandbox in the original issue: https://codesandbox.io/s/inspiring-fast-5jm9wc?file=/example.tsx
Drag scrollbar in the scrollable container does not close the popover.
Click trigger to open popover, keep mouse pointer within trigger and scroll away does not close the popover.
New Behavior
I updated the
useOnScrollOutsideto listen toscrollso it can close the popover in both cases.